# -*- coding: utf-8 -*-

# $Id: skeleton.py 2 2012-09-01 15:54:17Z alexis.roda.villalonga@gmail.com $

"""This module defines a class that models an skeleton.

An skeleton is a directory containing some templates and static files
plus some *intelligence* that can perform tasks programmatically. An
skeleton is defined by sub-classing the
:class:`dja_skel.lib.skeleton.Skeleton` class or one of its
subclasses (see :mod:`dja_skel.skeletons`):

.. code-block:: python

   class MySkeleton(Skeleton):
       variables = ("prjname", "version")
       skeleton_path = "templates/my_skeleton"
       name = "my_skeleton"

       def frobnicate(self):
           # this method performs some task on the
           # result of instantiating the skeleton
           pass


The instantiation of an skeleton is the process of rendering the
individual templates (performing variable substitution and so on),
copying the static files and executing the logic that fine tunes the
final result. In order to instantiate an skeleton a destination
directory and a dictionary mapping template variable names to their
actual value are required.

.. code-block:: python

   skel = MySkeleton()
   skel.instantiate("/path/to/some/dir", { "var" : "value" })


Any file within the ``skeleton_path`` directory whose name ends with
the ``_tmpl`` suffix is considered a template that must be
rendered. All other files will be copied verbatim.

File and directory names can be parameterised too, just enclose the
variable parts within braces and they will be expanded upon skeleton
instantiation. For example, if the skeleton directory contains a file
named ``{var}.py`` the previous instantiation will create a file
``value.py``.

An skeleton may be build upon other skeletons, such skeleton is named
a *compound* skeleton.

.. code-block:: python

   class MySkeleton(Skeleton):
       skeleton_path = "templates/my_skeleton"
       name = "my_skeleton"
       base_skeletons = (AnSkeleton, OtherSkeleton)


When a compound skeleton is instantiated in the first place the *base*
skeletons are instantiated, in reverse order of declaration, and then
the *parent* skeleton is instantiated. This way it's clear what files
prevail in case of conflict. That functionality makes easy building
complex skeletons based on simpler ones or overriding some parts of an
existing skeleton. In order to avoid variable clashes when
instantiating a compound skeleton every participant will receive a
shallow copy of the context dictionary, that way they can modify the
context as required without interferences.

The skeleton machinery does not force you to use a particular template
engine, instead it uses a pluggable architecture that eases using
whatever template engine you feel more comfortable with. See
:class:`dja_skel.lib.templating.Engine`.
"""

import locale
import logging
import os
import shutil
import sys

from dja_skel.lib import errors
from dja_skel.lib import entry_points


_log = logging.getLogger("dja_skel.lib")


class Skeleton(object) :
    """This class models an skeleton and provides a method for
    instantiating it.

    .. warning::

       Instances of this class are not thread safe although they can
       be safely reused after instantiation is finished.

    It defines the following class attributes:

    .. py:attribute:: base_skeletons = ()

       list of skeleton classes this one is based upon.

    .. py:attribute:: skeleton_path

       path to the directory where templates and static files are
       stored. It must be a relative path. It is first looked for in
       the directory containing the module that defines the skeleton,
       then in its parent until it's found or the root of the package
       is reached.

    .. py:attribute:: name

       name of the skeleton.

    .. py:attribute:: variables = ()

       an iterable of strings declaring variables required by the
       skeleton. This parameter is used for documentation purposes.

    .. py:attribute:: template_engine = "format"

       name of the template engine used to render the templates. By
       default the string method ``format`` is used. The value is
       looked up in the entry points defined in the group
       ``dja_skel.template_engine``. The first match is used.

    .. py:attribute:: template_engine_parametres = {}

       a dictionary whose values will be passed as keyword arguments
       to the template engine constructor. Default an empty dict.

    When an skeleton is instantiated the following steps are executed:

    #. the :meth:`before_instantiation` method is called. This is a
       good place to place initialisation code and the like.

    #. the :meth:`get_extra_context` method is called. This method is
       responsible for gathering extra context variables.

    #. the actual instantiation is performed

    #. the :meth:`after_instantiation` method is called. This is a
       good place to perform tasks that further customise the result
       of the instantiation.

    Subclasses may override the methods :meth:`before_instantiation`,
    :meth:`after_instantiation` and :meth:`get_extra_context`. Don't
    forget to call ``super`` in order to play nicely with base
    classes.
    """

    base_skeletons = ()
    name = None
    variables = ()
    skeleton_path = None
    template_engine = "format"
    template_engine_parameters = {}

    def __init__(self):
        self._engine = self._get_template_engine()
        self._base_skeleton_instances = []
        for skel_class in self._get_base_skeletons():
            self._base_skeleton_instances.append(skel_class())
        self._dest_dir = None
        self._context = {}
        self._encoding = None
        # assume that filenames are encoded with the system encoding
        self._path_encoding = locale.getpreferredencoding()

    def get_extra_context(self, context):
        """Gather extra context.

        This method is called before skeleton instatiation in order to
        gather extra context. It defines the variables:

        :skel_environ: the value of ``os.environ``

        :skel_root_path: the path to the destination directory
          passed to :meth:`instantiate`.

        :skel_encoding: the encoding passed to :meth:`instantiate`.

        Classes overriding this method can add variables to the
        ``context`` dictionary as required. Care must be taken in
        order to avoid name clashes. Variable names starting with the
        ``skel_`` prefix are reserved.

        :param context: a dictionary mapping variable names to values.
        :returns: the ``context`` dictionary.
        :rtype: dict
        """
        context["skel_environ"] = os.environ
        context["skel_root_path"] = self._dest_dir
        context["skel_encoding"] = self._encoding
        return context

    def instantiate(self, dest_dir, context, encoding="utf-8"):
        """Instantiate the skeleton.

        This method is the public entry point for the class.

        :param dest_dir: the path of the directory where the result
          will be saved. It's not required but is advisable to use an
          absolute path.

        :param context: a dictionary mapping variable names to values. It
          will be used for variable substitution in templates and file
          names.  Internally this class works with unicode strings, so
          it is expected that the string values are stored in the
          ``context`` as unicode strings.

        :param encoding: the desired encoding for the
          output files. It is only meaningful for templates, the
          static files are copied verbatim.
        """
        _log.info("Rendering '%s' skeleton at %s", self.name, dest_dir)
        self._check_variables(context)
        context = dict(context) # make a copy so that base skeletons
                                # won't pollute each other context
        if not os.path.exists(dest_dir):
            self._mkdir(dest_dir)
        for skel in self._base_skeleton_instances:
            skel.instantiate(dest_dir, context, encoding)
        self.before_instantiation(dest_dir, context, encoding)
        self.get_extra_context(context)
        self._instantiate_self()
        self.after_instantiation()

    def before_instantiation(self, dest_dir, context, encoding):
        """Performs initial setup tasks.

        This method is called before skeleton instantiation. It
        receives the same parameters that :meth:`instantiate`.
        """
        # store the context and the encoding in the instance to avoid
        # passing them around
        self._dest_dir = dest_dir
        self._context = context
        self._encoding = encoding


    def after_instantiation(self):
        """Performs post instantiation tasks.

        This method is called after the skeleton has been
        instantiated.
        """
        pass

    def _instantiate_self(self):
        """Instantiates 'this' skeleton.
        """
        _log.info("Instantiating: '%s'", self.name)
        dest_dir = self._dest_dir
        skel_dir = self._get_skel_abs_path()
        for path, dirs, files in os.walk(skel_dir):
            if ".svn" in dirs:
                dirs.remove(".svn")
            rel_dir = path[len(skel_dir):].lstrip("/")

            for i in files:
                real_i = i
                if real_i.endswith("_tmpl"):
                    real_i = real_i[:-5]
                src = os.path.join(path, i)
                dst = self._instantiate_path(os.path.join(dest_dir, rel_dir, real_i))
                self._copy(src, dst)

            for i in dirs:
                src = os.path.join(path, i)
                dst = self._instantiate_path(os.path.join(dest_dir, rel_dir, i))
                self._mkdir(dst, like=src)

        _log.info("Finished rendering '%s'", self.name)

    def _mkdir(self, path, like=None):
        """Create a directory if it does not exists.
        """
        _log.info("Create directory '%s'", path)
        if not os.path.exists(path):
            os.mkdir(path)
        if like is not None:
            shutil.copymode(like, path)

    def _copy(self, src, dst):
        """Copy a file or instantiate a template.
        """
        if src.endswith("_tmpl"):
            _log.info("Create '%s' from '%s'", dst, src)
            with file(dst, "w") as f:
                template = self._instantiate_template(src)
                f.write(template.encode(self._encoding))
        else:
            _log.info("Copy '%s' to '%s'", src, dst)
            shutil.copyfile(src, dst)
        shutil.copymode(src, dst)

    def _instantiate_path(self, path):
        """Replace variables in ``path``.

        As ``path`` is a filesystem path it is assumed that it's
        encoded using the system encoding, as returned by
        ``locale.getpreferredencoding()``.

        Returns the path encoded.
        """
        # the 'context' contains unicode values, so 'path' must be
        # decoded, formated and encoded back
        path = path.decode(self._path_encoding)
        try:
            path = path.format(**self._context)
        except KeyError, exc:
            raise errors.UndefinedVarError(
                exc.args[0],
                path
                )
        return path.encode(self._path_encoding)

    def _instantiate_template(self, path):
        """Instantiate a template.

        Returns an unicode string.
        """
        return self._engine.render(path, self._context)

    @classmethod
    def _check_variables(cls, context):
        """Ensures that ``context`` provides values for all the
        variables declared in the skeleton and its base skeletons.
        """
        has_errors = False
        for v in cls.variables:
            if v not in context:
                _log.critical("Context misses required variable '%s'", v)
                has_errors = True
        if has_errors:
            raise ValueError("Missing var(s) in context.")

    @classmethod
    def _get_base_skeletons(cls):
        """Yields the base skeletons.
        """
        for c in reversed(cls.base_skeletons):
            yield c

    @classmethod
    def _get_template_engine(cls):
        """Instantiates and returns a template engine.
        """
        engine_name = getattr(cls, "template_engine", "format")
        engine_kw = getattr(cls, "template_engine_parameters", {})
        engine_class = entry_points.load_first_entry_point(
            "dja_skel.template_engine",
            engine_name
            )
        return engine_class(**engine_kw)

    @classmethod
    def _get_skel_abs_path(cls):
        if getattr(cls, "skeleton_path", None) is None:
            raise ValueError(
                "'skeleton_path' attribute of '%s' is not set" % cls.__name__
                )
        module_parts = cls.__module__.split(".")
        while module_parts:
            abs_path = os.path.join(
                os.path.dirname(sys.modules[".".join(module_parts)].__file__),
                cls.skeleton_path
                )
            if os.path.exists(abs_path):
                return abs_path
            module_parts.pop()
        raise ValueError("No skeleton found for '%s'" % cls.name)
